--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit f9c8c46a983a15e91a217c7554a7d7100ebe2519
Parents : bf443b6
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-04T16:09:54-05:00
feat(crash-recovery): update crash recovery features with new UI elements and backend support for restoring database backups, including hints for users and maintenance tasks
Changes
12 files changed, 805 insertions(+), 11 deletions(-)
Diff
diff --git a/electron/backendCrashReport.js b/electron/backendCrashReport.js
index 11364eed..9daee0e6 100644
--- a/electron/backendCrashReport.js
+++ b/electron/backendCrashReport.js
@@ -256,7 +256,10 @@ function diagnoseBackendCrash(stderr, stdout, exitCode) {
}
if (combined.includes("database") && (combined.includes("corrupt") || combined.includes("malformed"))) {
- hints.push("Try Emergency Mode, or rename the storage folder after backing it up.");
+ hints.push("Use Restore latest backup on the crash screen if automatic backups are listed.");
+ hints.push("Try auto-repair on the crash screen to run SQLite recovery on the next launch.");
+ hints.push("Copy reset instructions from the crash screen to remove MeshChatX and Reticulum data folders and start fresh.");
+ hints.push("Emergency Mode can still open the app without the database so you can download backups from About.");
return {
category: "database",
summary: "The local database may be corrupted.",
diff --git a/electron/backendProcess.js b/electron/backendProcess.js
index 2b452ce3..87d9d413 100644
--- a/electron/backendProcess.js
+++ b/electron/backendProcess.js
@@ -223,15 +223,7 @@ function createBackendProcessManager(deps) {
);
}
- const requiredArguments = ["--headless", "--port", "9337"];
- if (!userProvidedArguments.includes("--reticulum-config-dir")) {
- requiredArguments.push("--reticulum-config-dir", reticulumConfigDir());
- }
- if (!userProvidedArguments.includes("--storage-dir")) {
- requiredArguments.push("--storage-dir", storageDir());
- }
-
- const proc = spawnFn(exePath, [...requiredArguments, ...userProvidedArguments], {
+ const proc = spawnFn(exePath, buildBackendArgs(), {
env: buildSpawnEnv(),
windowsHide: true,
});
@@ -293,6 +285,74 @@ function createBackendProcessManager(deps) {
}
}
+ function buildBackendArgs(extraArgs = []) {
+ const requiredArguments = ["--headless", "--port", "9337"];
+ if (!userProvidedArguments.includes("--reticulum-config-dir")) {
+ requiredArguments.push("--reticulum-config-dir", reticulumConfigDir());
+ }
+ if (!userProvidedArguments.includes("--storage-dir")) {
+ requiredArguments.push("--storage-dir", storageDir());
+ }
+ return [...requiredArguments, ...userProvidedArguments, ...extraArgs];
+ }
+
+ async function runMaintenanceTask(extraArgs, integrityStatusRef) {
+ if (!resolvedExePath) {
+ return { ok: false, error: "Backend executable is not configured." };
+ }
+ if (isRunning()) {
+ killChild("SIGTERM");
+ await new Promise((resolve) => setTimeout(resolve, 400));
+ }
+
+ return await new Promise((resolve) => {
+ const stdoutChunks = [];
+ const stderrChunks = [];
+ const proc = spawnFn(resolvedExePath, buildBackendArgs(extraArgs), {
+ env: buildSpawnEnv(),
+ windowsHide: true,
+ });
+ if (!proc || !proc.pid) {
+ resolve({ ok: false, error: "Failed to start backend maintenance task." });
+ return;
+ }
+
+ proc.stdout?.on("data", (chunk) => {
+ stdoutChunks.push(String(chunk));
+ });
+ proc.stderr?.on("data", (chunk) => {
+ stderrChunks.push(String(chunk));
+ });
+ proc.on("error", (error) => {
+ resolve({
+ ok: false,
+ error: error && error.message ? error.message : String(error),
+ stdout: stdoutChunks.join(""),
+ stderr: stderrChunks.join(""),
+ });
+ });
+ proc.on("exit", (code) => {
+ const stdout = stdoutChunks.join("");
+ const stderr = stderrChunks.join("");
+ if (code === 0) {
+ resolve({ ok: true, exitCode: code, stdout, stderr });
+ return;
+ }
+ resolve({
+ ok: false,
+ exitCode: code,
+ error: `Maintenance task exited with code ${code}`,
+ stdout,
+ stderr,
+ });
+ });
+ });
+ }
+
+ function getResolvedExecutablePath() {
+ return resolvedExePath;
+ }
+
async function openCrashReport(showCrashPageFn) {
if (!lastCrash) {
return { ok: false, error: "No backend crash report is available." };
@@ -323,6 +383,8 @@ function createBackendProcessManager(deps) {
isRunning,
killChild,
getJoinedLogs,
+ runMaintenanceTask,
+ getResolvedExecutablePath,
};
}
diff --git a/electron/crash.html b/electron/crash.html
index 6e0c4e31..8eee008c 100644
--- a/electron/crash.html
+++ b/electron/crash.html
@@ -80,6 +80,84 @@
<p id="saved-log-path" class="break-all text-[10px] text-slate-600 dark:text-zinc-400 px-1"></p>
+ <div
+ id="recovery-panel"
+ class="hidden rounded-xl border border-amber-200/80 bg-amber-50/70 p-4 space-y-3 dark:border-amber-900/50 dark:bg-amber-950/20"
+ >
+ <div>
+ <div
+ class="text-[10px] font-semibold uppercase tracking-wider text-amber-800 dark:text-amber-300"
+ >
+ Recovery
+ </div>
+ <p
+ id="recovery-summary"
+ class="mt-1 text-sm leading-relaxed text-amber-950 dark:text-amber-100"
+ ></p>
+ <ul
+ id="recovery-hints"
+ class="mt-2 list-disc space-y-1 pl-5 text-xs leading-relaxed text-amber-900 dark:text-amber-200"
+ ></ul>
+ </div>
+ <div id="backup-list" class="space-y-2"></div>
+ <div class="flex flex-wrap gap-2">
+ <button
+ id="btn-restore-latest"
+ type="button"
+ class="rounded-lg bg-emerald-600 px-3 py-1.5 text-[11px] font-semibold text-white hover:bg-emerald-500 disabled:opacity-50"
+ >
+ Restore latest backup
+ </button>
+ <button
+ id="btn-restore-picked"
+ type="button"
+ class="rounded-lg border border-amber-300 bg-white px-3 py-1.5 text-[11px] font-semibold text-amber-900 hover:bg-amber-100 dark:border-amber-800 dark:bg-zinc-900 dark:text-amber-100 dark:hover:bg-zinc-800"
+ >
+ Choose backup file
+ </button>
+ <button
+ id="btn-auto-repair"
+ type="button"
+ class="rounded-lg border border-amber-300 bg-white px-3 py-1.5 text-[11px] font-semibold text-amber-900 hover:bg-amber-100 dark:border-amber-800 dark:bg-zinc-900 dark:text-amber-100 dark:hover:bg-zinc-800"
+ >
+ Try auto-repair
+ </button>
+ <button
+ id="btn-emergency-mode"
+ type="button"
+ class="rounded-lg border border-amber-300 bg-white px-3 py-1.5 text-[11px] font-semibold text-amber-900 hover:bg-amber-100 dark:border-amber-800 dark:bg-zinc-900 dark:text-amber-100 dark:hover:bg-zinc-800"
+ >
+ Emergency mode
+ </button>
+ <button
+ id="btn-open-backups"
+ type="button"
+ class="rounded-lg border border-amber-300 bg-white px-3 py-1.5 text-[11px] font-semibold text-amber-900 hover:bg-amber-100 dark:border-amber-800 dark:bg-zinc-900 dark:text-amber-100 dark:hover:bg-zinc-800"
+ >
+ Open backups folder
+ </button>
+ <button
+ id="btn-copy-reset-guide"
+ type="button"
+ class="rounded-lg border border-amber-300 bg-white px-3 py-1.5 text-[11px] font-semibold text-amber-900 hover:bg-amber-100 dark:border-amber-800 dark:bg-zinc-900 dark:text-amber-100 dark:hover:bg-zinc-800"
+ >
+ Copy reset instructions
+ </button>
+ </div>
+ <p id="recovery-status" class="text-xs text-amber-900 dark:text-amber-200"></p>
+ <details class="rounded-lg border border-amber-200/70 bg-white/70 p-3 dark:border-amber-900/40 dark:bg-zinc-950/40">
+ <summary
+ class="cursor-pointer text-xs font-semibold text-amber-900 dark:text-amber-100"
+ >
+ Complete data removal guide
+ </summary>
+ <pre
+ id="cleanup-guide"
+ class="mt-2 max-h-48 overflow-auto whitespace-pre-wrap text-[10px] leading-relaxed text-slate-700 dark:text-zinc-300"
+ ></pre>
+ </details>
+ </div>
+
<div class="space-y-3">
<div class="flex flex-col sm:flex-row items-center justify-between gap-2 px-1">
<h3
@@ -214,8 +292,184 @@
document.documentElement.classList.toggle("dark", isDark);
}
- // Apply theme
+ function setRecoveryStatus(message, isError = false) {
+ const el = document.getElementById("recovery-status");
+ if (!el) {
+ return;
+ }
+ el.textContent = message || "";
+ el.classList.toggle("text-red-700", isError);
+ el.classList.toggle("dark:text-red-300", isError);
+ }
+
+ function formatBytes(size) {
+ if (!Number.isFinite(size) || size <= 0) {
+ return "0 B";
+ }
+ const units = ["B", "KB", "MB", "GB"];
+ let value = size;
+ let unit = 0;
+ while (value >= 1024 && unit < units.length - 1) {
+ value /= 1024;
+ unit += 1;
+ }
+ return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
+ }
+
+ function renderBackupList(backups) {
+ const list = document.getElementById("backup-list");
+ const restoreLatest = document.getElementById("btn-restore-latest");
+ if (!list || !restoreLatest) {
+ return;
+ }
+ list.innerHTML = "";
+ if (!Array.isArray(backups) || backups.length === 0) {
+ list.innerHTML =
+ '<p class="text-xs text-amber-900 dark:text-amber-200">No automatic backups were found in database-backups/ or snapshots/.</p>';
+ restoreLatest.disabled = true;
+ return;
+ }
+ restoreLatest.disabled = false;
+ const preview = backups.slice(0, 5);
+ for (const entry of preview) {
+ const row = document.createElement("div");
+ row.className =
+ "rounded-lg border border-amber-200/70 bg-white/80 px-3 py-2 text-[11px] text-amber-950 dark:border-amber-900/40 dark:bg-zinc-950/50 dark:text-amber-100";
+ const label = entry.suspicious ? `${entry.name} (suspicious)` : entry.name;
+ row.textContent = `${label} - ${formatBytes(entry.size)} - ${entry.kind}`;
+ list.appendChild(row);
+ }
+ }
+
+ async function restoreBackupPath(backupPath) {
+ if (!window.electron?.restoreDatabaseBackup) {
+ setRecoveryStatus("Restore is only available in the desktop app.", true);
+ return;
+ }
+ setRecoveryStatus("Restoring database backup. This can take a minute...");
+ const result = await window.electron.restoreDatabaseBackup(backupPath);
+ if (!result?.ok) {
+ const detail = result?.stderr || result?.error || "Restore failed.";
+ setRecoveryStatus(detail, true);
+ return;
+ }
+ setRecoveryStatus("Database restored. Relaunching...");
+ setTimeout(() => {
+ window.electron.relaunch();
+ }, 800);
+ }
+
+ async function initRecoveryPanel() {
+ if (!window.electron?.getCrashRecoveryInfo) {
+ return;
+ }
+ const info = await window.electron.getCrashRecoveryInfo();
+ if (!info) {
+ return;
+ }
+
+ const panel = document.getElementById("recovery-panel");
+ const summary = document.getElementById("recovery-summary");
+ const hints = document.getElementById("recovery-hints");
+ const cleanupGuide = document.getElementById("cleanup-guide");
+ const restoreLatest = document.getElementById("btn-restore-latest");
+ const restorePicked = document.getElementById("btn-restore-picked");
+ const autoRepair = document.getElementById("btn-auto-repair");
+ const emergencyMode = document.getElementById("btn-emergency-mode");
+ const openBackups = document.getElementById("btn-open-backups");
+ const copyResetGuide = document.getElementById("btn-copy-reset-guide");
+
+ if (cleanupGuide && info.cleanupGuide) {
+ cleanupGuide.textContent = info.cleanupGuide;
+ }
+
+ const diagnosis = info.diagnosis || {};
+ const showRecovery =
+ diagnosis.category === "database" ||
+ diagnosis.category === "python-crash" ||
+ (Array.isArray(info.backups) && info.backups.length > 0);
+
+ if (!panel || !showRecovery) {
+ return;
+ }
+
+ panel.classList.remove("hidden");
+ if (summary) {
+ summary.textContent = diagnosis.summary || "The backend failed during startup.";
+ }
+ if (hints) {
+ hints.innerHTML = "";
+ for (const hint of diagnosis.hints || []) {
+ const item = document.createElement("li");
+ item.textContent = hint;
+ hints.appendChild(item);
+ }
+ }
+
+ renderBackupList(info.backups || []);
+ let preferredBackup = info.preferredBackup || null;
+
+ if (restoreLatest) {
+ restoreLatest.addEventListener("click", async () => {
+ if (!preferredBackup?.path) {
+ setRecoveryStatus("No backup is available to restore.", true);
+ return;
+ }
+ await restoreBackupPath(preferredBackup.path);
+ });
+ }
+
+ if (restorePicked) {
+ restorePicked.addEventListener("click", async () => {
+ const picked = await window.electron.pickDatabaseBackup();
+ if (!picked) {
+ return;
+ }
+ await restoreBackupPath(picked);
+ });
+ }
+
+ if (autoRepair) {
+ autoRepair.addEventListener("click", async () => {
+ if (!window.electron?.relaunchAutoRecover) {
+ return;
+ }
+ setRecoveryStatus("Relaunching with auto-repair enabled...");
+ await window.electron.relaunchAutoRecover();
+ });
+ }
+
+ if (emergencyMode) {
+ emergencyMode.addEventListener("click", async () => {
+ if (!window.electron?.relaunchEmergency) {
+ return;
+ }
+ setRecoveryStatus("Relaunching in emergency mode...");
+ await window.electron.relaunchEmergency();
+ });
+ }
+
+ if (openBackups && info.paths?.backupsDir) {
+ openBackups.addEventListener("click", async () => {
+ if (window.electron?.openPath) {
+ await window.electron.openPath(info.paths.backupsDir);
+ }
+ });
+ }
+
+ if (copyResetGuide) {
+ copyResetGuide.addEventListener("click", async () => {
+ if (!info.cleanupGuide) {
+ return;
+ }
+ await navigator.clipboard.writeText(info.cleanupGuide);
+ setRecoveryStatus("Reset instructions copied to clipboard.");
+ });
+ }
+ }
+
applyTheme(detectPreferredTheme());
+ initRecoveryPanel();
</script>
</body>
</html>
diff --git a/electron/main.js b/electron/main.js
index c2b6d50f..757a3e1a 100644
--- a/electron/main.js
+++ b/electron/main.js
@@ -17,6 +17,7 @@ const fs = require("fs");
const path = require("node:path");
const { createBackendProcessManager } = require("./backendProcess");
+const { getCrashRecoveryInfo } = require("./offlineRecovery");
const {
getUserProvidedArguments,
formatRenderProcessGoneDetails,
@@ -209,6 +210,56 @@ ipcMain.handle("open-backend-crash-report", async () => {
return { ok: true };
});
+ipcMain.handle("crash-recovery-info", () => {
+ const manager = getBackendManager();
+ const lastCrash = manager.getLastCrash() || {};
+ const logs = manager.getJoinedLogs();
+ return getCrashRecoveryInfo({
+ storageDir: getDefaultStorageDir(),
+ reticulumConfigDir: getDefaultReticulumConfigDir(),
+ platform: process.platform,
+ portableExecutableDir: process.env.PORTABLE_EXECUTABLE_DIR || null,
+ stderr: lastCrash.stderr || logs.stderr || "",
+ stdout: lastCrash.stdout || logs.stdout || "",
+ exitCode: lastCrash.code != null ? lastCrash.code : null,
+ });
+});
+
+ipcMain.handle("restore-database-backup", async (_event, backupPath) => {
+ if (!backupPath || typeof backupPath !== "string") {
+ return { ok: false, error: "No backup path provided." };
+ }
+ const ctx = {
+ app,
+ getDefaultStorageDir,
+ getDefaultReticulumConfigDir,
+ getUserProvidedArguments,
+ };
+ const { isAllowedShellPath } = require("./shellPathGuard");
+ if (!isAllowedShellPath(backupPath, ctx)) {
+ return { ok: false, error: "Backup path is not allowed." };
+ }
+ if (!fs.existsSync(backupPath)) {
+ return { ok: false, error: "Backup file was not found." };
+ }
+ return await getBackendManager().runMaintenanceTask(["--restore-db", backupPath], integrityStatus);
+});
+
+ipcMain.handle("pick-database-backup", async () => {
+ const win = getDialogParentWindow();
+ if (!win) {
+ return null;
+ }
+ const { canceled, filePaths } = await dialog.showOpenDialog(win, {
+ properties: ["openFile"],
+ filters: [{ name: "MeshChatX backup", extensions: ["zip"] }],
+ });
+ if (canceled || !filePaths || filePaths.length === 0) {
+ return null;
+ }
+ return filePaths[0];
+});
+
// add support for showing an alert window via ipc
ipcMain.handle("alert", async (event, message) => {
return await dialog.showMessageBox(mainWindow, {
@@ -271,6 +322,18 @@ ipcMain.handle("relaunch-emergency", () => {
quit();
});
+ipcMain.handle("relaunch-auto-recover", () => {
+ const relaunchOptions = {
+ args: process.argv.slice(1).concat(["--auto-recover"]),
+ };
+ if (!process.defaultApp && process.platform === "linux" && process.env.APPIMAGE) {
+ relaunchOptions.execPath = process.env.APPIMAGE;
+ }
+ app.relaunch(relaunchOptions);
+ isQuiting = true;
+ quit();
+});
+
ipcMain.handle("shutdown", () => {
isQuiting = true;
quit();
diff --git a/electron/offlineRecovery.js b/electron/offlineRecovery.js
new file mode 100644
index 00000000..8b05c66d
--- /dev/null
+++ b/electron/offlineRecovery.js
@@ -0,0 +1,173 @@
+"use strict";
+
+const fs = require("node:fs");
+const path = require("node:path");
+
+const { diagnoseBackendCrash } = require("./backendCrashReport");
+
+/**
+ * @param {string} dir
+ * @param {"auto"|"snapshot"} kind
+ * @returns {Array<{name: string, path: string, size: number, createdAt: string, kind: string, suspicious: boolean}>}
+ */
+function listZipBackupsInDir(dir, kind) {
+ if (!dir || !fs.existsSync(dir)) {
+ return [];
+ }
+ const entries = [];
+ for (const name of fs.readdirSync(dir)) {
+ if (!name.endsWith(".zip")) {
+ continue;
+ }
+ const fullPath = path.join(dir, name);
+ try {
+ const stats = fs.statSync(fullPath);
+ if (!stats.isFile()) {
+ continue;
+ }
+ entries.push({
+ name,
+ path: fullPath,
+ size: stats.size,
+ createdAt: new Date(stats.mtimeMs).toISOString(),
+ kind,
+ suspicious: /SUSPICIOUS/i.test(name),
+ });
+ } catch {
+ // skip unreadable entries
+ }
+ }
+ return entries;
+}
+
+/**
+ * @param {string} storageDir
+ * @returns {Array<{name: string, path: string, size: number, createdAt: string, kind: string, suspicious: boolean}>}
+ */
+function listRecoveryBackups(storageDir) {
+ if (!storageDir) {
+ return [];
+ }
+ const auto = listZipBackupsInDir(path.join(storageDir, "database-backups"), "auto");
+ const snapshots = listZipBackupsInDir(path.join(storageDir, "snapshots"), "snapshot");
+ return [...auto, ...snapshots].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
+}
+
+/**
+ * @param {Array<{suspicious: boolean, createdAt: string}>} backups
+ * @returns {object|null}
+ */
+function pickPreferredRestoreBackup(backups) {
+ if (!Array.isArray(backups) || backups.length === 0) {
+ return null;
+ }
+ const healthy = backups.filter((entry) => !entry.suspicious);
+ if (healthy.length > 0) {
+ return healthy[0];
+ }
+ return backups[0];
+}
+
+/**
+ * @param {object} ctx
+ * @param {string} ctx.storageDir
+ * @param {string} ctx.reticulumConfigDir
+ * @param {string} ctx.platform
+ * @param {string|null} ctx.portableExecutableDir
+ * @returns {string}
+ */
+function buildDataCleanupGuide(ctx) {
+ const storageDir = ctx.storageDir || "~/.reticulum-meshchatx";
+ const reticulumDir = ctx.reticulumConfigDir || "~/.reticulum";
+ const legacyDir = storageDir.replace(/meshchatx$/i, "meshchat").replace(/MeshChatX$/i, "meshchat");
+ const lines = [
+ "Complete MeshChatX / Reticulum data reset",
+ "",
+ "Quit MeshChatX completely before deleting anything. On Windows, also end ReticulumMeshChatX.exe in Task Manager if it is still running.",
+ "",
+ "MeshChatX storage (database, identities, automatic backups, logs):",
+ ` ${storageDir}`,
+ "",
+ "Reticulum network stack (config, ratchets, path table, interface state):",
+ ` ${reticulumDir}`,
+ "",
+ ];
+
+ if (legacyDir && legacyDir !== storageDir) {
+ lines.push("Legacy Reticulum MeshChat storage (only if you migrated from the old app):");
+ lines.push(` ${legacyDir}`);
+ lines.push("");
+ }
+
+ if (ctx.platform === "win32" && ctx.portableExecutableDir) {
+ lines.push("Portable Windows install: the folders above are next to MeshChatX.exe, not in your user profile.");
+ lines.push(` Portable app folder: ${ctx.portableExecutableDir}`);
+ lines.push("");
+ }
+
+ lines.push("Linux / macOS default locations:");
+ lines.push(" ~/.reticulum-meshchatx/");
+ lines.push(" ~/.reticulum/");
+ lines.push(" ~/.reticulum-meshchat/ (legacy, if present)");
+ lines.push("");
+ lines.push("Windows default locations:");
+ lines.push(" %USERPROFILE%\\.reticulum-meshchatx\\");
+ lines.push(" %USERPROFILE%\\.reticulum\\");
+ lines.push(" %USERPROFILE%\\.reticulum-meshchat\\ (legacy, if present)");
+ lines.push("");
+ lines.push(
+ "Removing these folders deletes your local identity, messages, contacts, and network path cache. You will get a new identity on the next launch unless you restore from a backup zip first.",
+ );
+ lines.push("");
+ lines.push(
+ "Automatic database backups are kept under database-backups/ inside the MeshChatX storage folder when the app has run successfully before.",
+ );
+
+ return lines.join("\n");
+}
+
+/**
+ * @param {object} params
+ * @param {string} params.storageDir
+ * @param {string} params.reticulumConfigDir
+ * @param {string} params.platform
+ * @param {string|null} params.portableExecutableDir
+ * @param {string} params.stderr
+ * @param {string} params.stdout
+ * @param {number|null} params.exitCode
+ * @returns {object}
+ */
+function getCrashRecoveryInfo(params) {
+ const backups = listRecoveryBackups(params.storageDir);
+ const preferredBackup = pickPreferredRestoreBackup(backups);
+ const diagnosis = diagnoseBackendCrash(params.stderr || "", params.stdout || "", params.exitCode ?? null);
+ const paths = {
+ storageDir: params.storageDir || null,
+ reticulumConfigDir: params.reticulumConfigDir || null,
+ backupsDir: params.storageDir ? path.join(params.storageDir, "database-backups") : null,
+ snapshotsDir: params.storageDir ? path.join(params.storageDir, "snapshots") : null,
+ logsDir: params.storageDir ? path.join(params.storageDir, "logs") : null,
+ };
+ const cleanupGuide = buildDataCleanupGuide({
+ storageDir: params.storageDir,
+ reticulumConfigDir: params.reticulumConfigDir,
+ platform: params.platform,
+ portableExecutableDir: params.portableExecutableDir,
+ });
+
+ return {
+ diagnosis,
+ paths,
+ backups,
+ preferredBackup,
+ cleanupGuide,
+ supportsOfflineRestore: Boolean(params.storageDir),
+ };
+}
+
+module.exports = {
+ buildDataCleanupGuide,
+ getCrashRecoveryInfo,
+ listRecoveryBackups,
+ pickPreferredRestoreBackup,
+};
diff --git a/electron/preload.js b/electron/preload.js
index 3b686904..09bcee15 100644
--- a/electron/preload.js
+++ b/electron/preload.js
@@ -49,6 +49,22 @@ contextBridge.exposeInMainWorld("electron", {
return await ipcRenderer.invoke("relaunch-emergency");
},
+ relaunchAutoRecover: async function () {
+ return await ipcRenderer.invoke("relaunch-auto-recover");
+ },
+
+ getCrashRecoveryInfo: async function () {
+ return await ipcRenderer.invoke("crash-recovery-info");
+ },
+
+ restoreDatabaseBackup: async function (backupPath) {
+ return await ipcRenderer.invoke("restore-database-backup", backupPath);
+ },
+
+ pickDatabaseBackup: async function () {
+ return await ipcRenderer.invoke("pick-database-backup");
+ },
+
// allow shutting down app in electron browser window
shutdown: async function () {
return await ipcRenderer.invoke("shutdown");
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index e1bf8a5c..b7cf468f 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -6431,6 +6431,7 @@ class ReticulumMeshChat:
},
"reticulum_config_path": self._api_reticulum_config_path(),
"host_platform": sys.platform,
+ **self._landlock_status_dict(),
"is_connected_to_shared_instance": is_connected_to_shared_instance,
"shared_instance_address": shared_instance_address,
"is_transport_enabled": (
diff --git a/meshchatx/src/frontend/components/about/AboutPage.vue b/meshchatx/src/frontend/components/about/AboutPage.vue
index dcd40bcb..979f6147 100644
--- a/meshchatx/src/frontend/components/about/AboutPage.vue
+++ b/meshchatx/src/frontend/components/about/AboutPage.vue
@@ -459,6 +459,36 @@
}}</span>
<span class="font-mono text-xs font-bold">{{ environmentInfo.platform }}</span>
</div>
+ <div
+ v-if="isLinuxHost && appInfo.landlock_requested !== undefined"
+ class="flex flex-col gap-1"
+ >
+ <div class="flex items-center justify-between gap-3">
+ <span class="text-[10px] font-black text-teal-500 uppercase tracking-wider">{{
+ $t("app.landlock_status")
+ }}</span>
+ <span
+ class="font-mono text-xs font-bold shrink-0"
+ :class="
+ appInfo.landlock_active
+ ? 'text-green-600 dark:text-green-400'
+ : 'text-amber-600 dark:text-amber-400'
+ "
+ >
+ {{
+ appInfo.landlock_active
+ ? $t("app.landlock_active")
+ : $t("app.landlock_inactive")
+ }}
+ </span>
+ </div>
+ <p
+ v-if="!appInfo.landlock_active && landlockInactiveReason"
+ class="text-[10px] leading-snug opacity-70"
+ >
+ {{ landlockInactiveReason }}
+ </p>
+ </div>
<div class="flex items-center justify-between">
<span class="text-[10px] font-black text-amber-500 uppercase tracking-wider">{{
$t("about.env_language")
@@ -1115,6 +1145,21 @@ export default {
return "";
}
},
+ isLinuxHost() {
+ return this.appInfo && this.appInfo.host_platform === "linux";
+ },
+ landlockInactiveReason() {
+ if (!this.appInfo || this.appInfo.landlock_active) {
+ return null;
+ }
+ if (this.appInfo.landlock_kernel_supported === false) {
+ return this.$t("app.landlock_kernel_unsupported");
+ }
+ if (this.appInfo.landlock_disabled_by_env) {
+ return this.$t("app.landlock_disabled_by_env");
+ }
+ return this.$t("app.landlock_inactive");
+ },
environmentInfo() {
const ua = typeof navigator !== "undefined" ? navigator.userAgent || "" : "";
let platform = typeof navigator !== "undefined" && navigator.platform ? navigator.platform : "";
diff --git a/tests/backend/api_json_contract_schemas.py b/tests/backend/api_json_contract_schemas.py
index d8e0d9a4..2ff23900 100644
--- a/tests/backend/api_json_contract_schemas.py
+++ b/tests/backend/api_json_contract_schemas.py
@@ -155,6 +155,11 @@ APP_INFO_BODY_SCHEMA: dict = {
},
"tutorial_seen": {"type": "boolean"},
"changelog_seen_version": {"type": "string"},
+ "landlock_kernel_supported": {"type": "boolean"},
+ "landlock_requested": {"type": "boolean"},
+ "landlock_auto_enabled": {"type": "boolean"},
+ "landlock_disabled_by_env": {"type": "boolean"},
+ "landlock_active": {"type": "boolean"},
},
"additionalProperties": True,
}
diff --git a/tests/electron/backendCrashReport.test.js b/tests/electron/backendCrashReport.test.js
index 4cfdac60..88205ee2 100644
--- a/tests/electron/backendCrashReport.test.js
+++ b/tests/electron/backendCrashReport.test.js
@@ -50,4 +50,10 @@ describe("electron/backendCrashReport", () => {
const diagnosis = diagnoseBackendCrash("", "OSError: [Errno 98] Address already in use", 1);
expect(diagnosis.category).toBe("port-conflict");
});
+
+ it("diagnoseBackendCrash detects database corruption", () => {
+ const diagnosis = diagnoseBackendCrash("database disk image is malformed", "", 1);
+ expect(diagnosis.category).toBe("database");
+ expect(diagnosis.hints.some((hint) => hint.includes("Restore latest backup"))).toBe(true);
+ });
});
diff --git a/tests/electron/offlineRecovery.test.js b/tests/electron/offlineRecovery.test.js
new file mode 100644
index 00000000..91a1b42d
--- /dev/null
+++ b/tests/electron/offlineRecovery.test.js
@@ -0,0 +1,73 @@
+import { describe, expect, it } from "vitest";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import {
+ buildDataCleanupGuide,
+ getCrashRecoveryInfo,
+ listRecoveryBackups,
+ pickPreferredRestoreBackup,
+} from "../../electron/offlineRecovery.js";
+
+describe("electron/offlineRecovery", () => {
+ it("lists automatic and snapshot backups newest first", () => {
+ const storageDir = fs.mkdtempSync(path.join(os.tmpdir(), "meshchatx-recovery-"));
+ try {
+ const autoDir = path.join(storageDir, "database-backups");
+ const snapshotDir = path.join(storageDir, "snapshots");
+ fs.mkdirSync(autoDir, { recursive: true });
+ fs.mkdirSync(snapshotDir, { recursive: true });
+ fs.writeFileSync(path.join(autoDir, "backup-old.zip"), "a");
+ fs.writeFileSync(path.join(autoDir, "backup-new.zip"), "ab");
+ fs.writeFileSync(path.join(snapshotDir, "manual.zip"), "abc");
+
+ const now = Date.now();
+ fs.utimesSync(path.join(autoDir, "backup-old.zip"), now / 1000 - 120, now / 1000 - 120);
+ fs.utimesSync(path.join(autoDir, "backup-new.zip"), now / 1000, now / 1000);
+ fs.utimesSync(path.join(snapshotDir, "manual.zip"), now / 1000 - 30, now / 1000 - 30);
+
+ const backups = listRecoveryBackups(storageDir);
+ expect(backups.map((entry) => entry.name)).toEqual([
+ "backup-new.zip",
+ "manual.zip",
+ "backup-old.zip",
+ ]);
+ } finally {
+ fs.rmSync(storageDir, { recursive: true, force: true });
+ }
+ });
+
+ it("prefers non-suspicious backups for restore", () => {
+ const backups = [
+ { name: "backup-SUSPICIOUS-1.zip", suspicious: true, createdAt: "2026-01-02T00:00:00.000Z" },
+ { name: "backup-2.zip", suspicious: false, createdAt: "2026-01-01T00:00:00.000Z" },
+ ];
+ expect(pickPreferredRestoreBackup(backups)?.name).toBe("backup-2.zip");
+ });
+
+ it("builds cleanup guide with storage and reticulum paths", () => {
+ const guide = buildDataCleanupGuide({
+ storageDir: "/home/user/.reticulum-meshchatx",
+ reticulumConfigDir: "/home/user/.reticulum",
+ platform: "linux",
+ portableExecutableDir: null,
+ });
+ expect(guide).toContain("/home/user/.reticulum-meshchatx");
+ expect(guide).toContain("/home/user/.reticulum");
+ expect(guide).toContain("Quit MeshChatX");
+ });
+
+ it("detects database corruption in crash recovery info", () => {
+ const info = getCrashRecoveryInfo({
+ storageDir: "/tmp/meshchatx",
+ reticulumConfigDir: "/tmp/reticulum",
+ platform: "linux",
+ portableExecutableDir: null,
+ stderr: "sqlite3.DatabaseError: database disk image is malformed",
+ stdout: "",
+ exitCode: 1,
+ });
+ expect(info.diagnosis.category).toBe("database");
+ expect(info.cleanupGuide).toContain("Complete MeshChatX / Reticulum data reset");
+ });
+});
diff --git a/tests/frontend/AboutPage.test.js b/tests/frontend/AboutPage.test.js
index 6b4fdc42..a0b3e558 100644
--- a/tests/frontend/AboutPage.test.js
+++ b/tests/frontend/AboutPage.test.js
@@ -423,4 +423,97 @@ describe("AboutPage.vue", () => {
expect(wrapper.text()).toContain("about.database_path");
expect(wrapper.text()).toContain("about.path_unknown");
});
+
+ it("shows landlock status on Linux when active", async () => {
+ axiosMock.get.mockImplementation((url) => {
+ if (url === "/api/v1/app/info")
+ return Promise.resolve({
+ data: {
+ app_info: {
+ version: "1.0.0",
+ host_platform: "linux",
+ landlock_requested: true,
+ landlock_active: true,
+ landlock_kernel_supported: true,
+ landlock_auto_enabled: true,
+ landlock_disabled_by_env: false,
+ },
+ },
+ });
+ if (url === "/api/v1/config") return Promise.resolve({ data: { config: {} } });
+ if (url === "/api/v1/database/health") return Promise.resolve({ data: { database: {} } });
+ if (url === "/api/v1/database/snapshots") return Promise.resolve({ data: [] });
+ return Promise.reject(new Error("Not found"));
+ });
+
+ const wrapper = mountAboutPage();
+ await vi.runOnlyPendingTimers();
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.text()).toContain("app.landlock_status");
+ expect(wrapper.text()).toContain("app.landlock_active");
+ expect(wrapper.text()).not.toContain("app.landlock_kernel_unsupported");
+ });
+
+ it("shows landlock inactive reason on Linux", async () => {
+ axiosMock.get.mockImplementation((url) => {
+ if (url === "/api/v1/app/info")
+ return Promise.resolve({
+ data: {
+ app_info: {
+ version: "1.0.0",
+ host_platform: "linux",
+ landlock_requested: false,
+ landlock_active: false,
+ landlock_kernel_supported: false,
+ landlock_auto_enabled: false,
+ landlock_disabled_by_env: false,
+ },
+ },
+ });
+ if (url === "/api/v1/config") return Promise.resolve({ data: { config: {} } });
+ if (url === "/api/v1/database/health") return Promise.resolve({ data: { database: {} } });
+ if (url === "/api/v1/database/snapshots") return Promise.resolve({ data: [] });
+ return Promise.reject(new Error("Not found"));
+ });
+
+ const wrapper = mountAboutPage();
+ await vi.runOnlyPendingTimers();
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.text()).toContain("app.landlock_inactive");
+ expect(wrapper.text()).toContain("app.landlock_kernel_unsupported");
+ });
+
+ it("hides landlock status on non-Linux platforms", async () => {
+ axiosMock.get.mockImplementation((url) => {
+ if (url === "/api/v1/app/info")
+ return Promise.resolve({
+ data: {
+ app_info: {
+ version: "1.0.0",
+ host_platform: "darwin",
+ landlock_requested: false,
+ landlock_active: false,
+ landlock_kernel_supported: false,
+ landlock_auto_enabled: false,
+ landlock_disabled_by_env: false,
+ },
+ },
+ });
+ if (url === "/api/v1/config") return Promise.resolve({ data: { config: {} } });
+ if (url === "/api/v1/database/health") return Promise.resolve({ data: { database: {} } });
+ if (url === "/api/v1/database/snapshots") return Promise.resolve({ data: [] });
+ return Promise.reject(new Error("Not found"));
+ });
+
+ const wrapper = mountAboutPage();
+ await vi.runOnlyPendingTimers();
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.text()).not.toContain("app.landlock_status");
+ });
});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────